昨天整理了載入進度條的原理:GLTFLoader.load 的第三個參數 onProgress,下載過程中會反覆被呼叫,收到的 ProgressEvent 有 loaded、total、lengthComputable 三個欄位。
今天把這些原理變成程式碼,加進我放在 GitHub 上整理過的專案。
我的 .glb 模型網頁打開後,需要有一些載入時間,所以在模型載入完成前,會看到畫面一片黑。為了讓使用者知道什麼時候載好,昨天的進度條功能就很適合放進來使用
昨天提到,不是每次都知道檔案總共多大(要看伺服器有沒有回傳 Content-Length),所以要處理兩種情況:
| 情況 | 文字 | 長條 |
|---|---|---|
| 算得出總大小 | 載入中… 50% | 依百分比變長 |
| 檔案載入到 100% | 檔案載入完成,處理模型中… | 滿 |
| 算不出總大小 | 載入中… 已載入 10.0 MB | 來回滑動(不代表百分比) |
| 載入失敗 | 載入失敗,請重新整理,或查看 Console 的錯誤訊息 | 空 |
為什麼 100% 要多一個「處理模型中」?因為上面說的第 ② 階段:檔案載入到 100% 之後,還要解析 .glb,解析完才呼叫 onLoad。如果這時文字還停在「100%」,使用者會以為卡住了。
我的程式已經拆成幾個模組,這次要改的是:
| 檔案 | 負責的事 | 這次的改動 |
|---|---|---|
index.html |
網頁結構 | 加一個進度條區塊 |
style.css |
樣式 | 加進度條的樣式 |
ui.js |
畫面上的顯示 | 加三個函式:更新、隱藏、顯示錯誤 |
main.js |
把各個模組接起來 | 把這三個函式接到載入流程上 |
loader.js 不用動:它只負責建立 GLTFLoader,進度要怎麼處理,是在呼叫 load 的時候傳進去的。
ui.js,是因為它和之前的提示訊息、結果面板一樣,都是「畫面上要顯示什麼」。以下標有 ★ 的都是今天新加的。
index.html 加進度條區塊在 #hud 後面加:
<!-- ★ 新增:載入進度條 -->
<div id="loading">
<div class="loading-card">
<p id="loading-text">載入中…</p>
<div class="loading-track"><div id="loading-bar"></div></div>
</div>
</div>
#loading-text:顯示文字。.loading-track:灰色的底軌。#loading-bar:裡面會變長的那一條。style.css 加樣式/* ★ 新增:載入進度條 */
#loading {
position: fixed;
inset: 0; /* 蓋滿整個視窗 */
z-index: 10; /* 疊在畫布與其他面板上面 */
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.75);
color: #fff;
transition: opacity 0.4s ease;
}
#loading.hidden {
opacity: 0;
pointer-events: none; /* 載入完成後,不擋住下面的畫布 */
}
.loading-card {
width: min(80vw, 360px);
text-align: center;
}
#loading-text {
margin: 0 0 12px;
font-size: 18px;
font-variant-numeric: tabular-nums;
white-space: pre-line; /* 文字裡的換行字元 \n 要顯示成換行,若沒有這行設定\n 沒有作用 */
}
.loading-track {
height: 10px;
border-radius: 5px;
background: rgba(255, 255, 255, 0.25);
overflow: hidden; /* 讓裡面的長條不會超出圓角 */
}
#loading-bar {
width: 0%;
height: 100%;
background: #4ade80;
transition: width 0.2s ease;
}
/* 算不出總大小時:長條改成來回滑動,不代表百分比 */
#loading-bar.indeterminate {
width: 40%;
transition: none;
animation: loading-slide 1.2s ease-in-out infinite;
}
@keyframes loading-slide {
from { transform: translateX(-100%); }
to { transform: translateX(250%); }
}
幾個重點:
inset: 0 加 position: fixed:蓋滿整個視窗,跟結果面板的做法一樣。z-index: 10:數字越大,疊得越上面。進度條要蓋住畫布和左上角的計時。#loading.hidden 裡的 pointer-events: none:載入完成後,進度條雖然變透明了,還在原地,如果不加這行,它會擋住滑鼠,畫布就拖不動了。.indeterminate:長條寬度固定 40%,用動畫從左邊滑到右邊。translateX(-100%) 是往左移自己的一個寬度(從底軌左邊外面開始),250% 是往右移 2.5 個寬度,剛好滑到底軌的右端。@keyframes 是什麼?
依 MDN 的說明:
The
@keyframesCSS at-rule controls the intermediate steps in a CSS animation sequence by defining styles for keyframes (or waypoints) along the animation sequence.
意思是:@keyframes 用來定義動畫的「關鍵畫面」,中間的過程由瀏覽器自動補。名稱 loading-slide 是自己取的。
這裡的起點是 translateX(-100%):translateX 是水平移動,-100% 是往左移「自己寬度的 100%」,長條剛好整個在底軌左邊外面(被 overflow: hidden 藏起來)。終點是 250%:往右移自己寬度的 2.5 倍,因為長條寬度是底軌的 40%,2.5 倍剛好等於底軌的 100%,長條就整個滑出右邊。
跑完一輪(到 to)之後,會直接跳回起點,再從左邊重新跑一輪,加上 infinite 後會一直重複左到右的動畫。因為跳回去的位置,長條是在底軌外面(被藏起來),所以眼睛看不到跳回去的瞬間,看起來就是「從左邊一次又一次滑進來」。
如果想要真的「來回」,在 animation 後面加 alternate:
animation: loading-slide 1.2s ease-in-out infinite alternate;
MDN 對 alternate 的說明是:
The animation reverses direction each cycle, with the first iteration being played forwards.
也就是第一輪由左往右,第二輪倒過來由右往左,一直交替。不過這樣起點和終點的長條都在底軌外面,會有一小段時間完全看不到長條,要不要用 alternate 看個人喜好,可以兩種都試試看。
ui.js 加三個函式在檔案最後面加:
// ★ 新增:載入進度條
const loading = document.getElementById('loading');
const loadingText = document.getElementById('loading-text');
const loadingBar = document.getElementById('loading-bar');
export function updateLoading(event) {
if (event.lengthComputable) {
// 算得出總大小:顯示百分比
const percent = Math.min(100, Math.floor((event.loaded / event.total) * 100));
loadingBar.classList.remove('indeterminate');
loadingBar.style.width = `${percent}%`;
loadingText.textContent = percent < 100 ? `載入中… ${percent}%` : '檔案載入完成,處理模型中…';
} else {
// 算不出總大小:只能顯示已載入多少 MB,長條改成來回滑動
const mb = (event.loaded / (1024 * 1024)).toFixed(1);
loadingBar.classList.add('indeterminate');
loadingBar.style.width = '';
loadingText.textContent = `載入中… 已載入 ${mb} MB`;
}
}
export function hideLoading() {
loading.classList.add('hidden');
}
export function showLoadError() {
loadingBar.classList.remove('indeterminate');
loadingBar.style.width = '0%';
loadingText.textContent = '載入失敗,請重新整理,或查看 Console 的錯誤訊息';
}
逐行說明 updateLoading:
event.lengthComputable:昨天說明過,是 true 才能算百分比。Math.floor(...):無條件捨去,讓 99.9% 顯示成 99%,不會在還沒完成時就顯示 100%。Math.min(100, ...):保險。萬一已載入量超過總大小,百分比也不會超過 100。percent < 100 ? ... : ...:到 100% 就改顯示「處理模型中」,原因在第一節。loadingBar.style.width = '':在「算不出總大小」那一支,要把前面設定過的寬度清掉。直接寫在元素上的 style.width 優先於樣式表,不清掉的話,.indeterminate 裡的 width: 40% 就套不上。(1024 * 1024):1 MB 等於 1,048,576 位元組。.toFixed(1):保留一位小數。main.js 接上三個函式三處改動。
第一處,匯入:
import { hideResult, showResult, showToast, updateLoading, hideLoading, showLoadError } from './ui.js'; // ★ 多匯入三個
第二處和第三處,在 loader.load 裡:
loader.load(
'3d_unseen_true.glb',
(gltf) => {
// ……原本載入完成後做的事,都不用動……
hideLoading(); // ★ 模型處理完成,收起進度條
},
updateLoading, // ★ 原本是 undefined,載入檔案的過程中會反覆呼叫
(error) => {
console.error('載入失敗', error);
showLoadError(); // ★ 失敗時,進度條改顯示錯誤訊息
}
);
undefined 換成 updateLoading。注意這裡沒有加括號:是把函式本身交出去,讓 GLTFLoader 在每次有進度時自己呼叫,加了括號就變成現在立刻執行一次。hideLoading() 放在 onLoad 的最後面,代表模型解析完、也加進場景之後,才把進度條收起來。console.error,現在多了一行,讓使用者在畫面上也看得到失敗。#loading 一開始就顯示,文字是「載入中…」。loader.load(...) 開始載入 .glb 檔案。GLTFLoader 就呼叫一次 updateLoading,文字與長條跟著更新。onLoad 被呼叫,場景加好、彩蛋標好,最後呼叫 hideLoading(),進度條淡出。onError 被呼叫,進度條改顯示錯誤訊息,不會消失。main.js 裡的 '3d_unseen_true.glb' 改成不存在的檔名,重新整理,應該看到「載入失敗」,Console 也會有錯誤訊息。測完記得改回來。Content-Length。今天把昨天的原理做成了程式:
ui.js 負責顯示(更新、隱藏、顯示錯誤),main.js 負責接線,loader.js 不用動。GLTFLoader 是檔案載入完才解析,而解析這段沒有進度可以顯示。下一步要把專案放到 GitHub Pages,讓任何人打開網址就能玩。部署之後,才會知道伺服器有沒有回傳檔案總大小,進度條會顯示成百分比還是 MB。